feat: 카카오 알림톡 / SMS / 이메일 전송 기능 추가#40
Conversation
There was a problem hiding this comment.
Pull request overview
본 PR은 운영진이 어드민에서 이메일(Gmail OAuth2 SMTP) / NHN Cloud SMS / NHN Cloud 카카오 알림톡을 템플릿 기반으로 미리보기 및 발송(History 생성/재시도)할 수 있도록 알림 서브시스템을 추가합니다.
Changes:
notification앱(템플릿/히스토리 모델, 템플릿 렌더링, NHN 동기화) 및 프리뷰 HTML 템플릿 추가- Gmail OAuth2 기반 SMTP 백엔드와 NHN Cloud(SMS/알림톡) 외부 API 클라이언트 추가 및 설정 확장
- 어드민 API(ViewSet/Serializer/Filter/URL) 및 관련 테스트 추가, Google OAuth2 authorize/redirect 플로우에 PKCE(code_verifier) 세션 저장 추가
Reviewed changes
Copilot reviewed 34 out of 37 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| app/notification/test/template_test.py | 템플릿 변수 추출/렌더링/프리뷰 렌더링 동작 테스트 추가 |
| app/notification/test/sms_test.py | SMS/MMS 렌더링 및 History 전송 상태 전이 테스트 추가 |
| app/notification/test/kakao_sync_test.py | NHN Cloud 알림톡 템플릿 동기화 및 로컬 CUD 차단 테스트 추가 |
| app/notification/test/history_send_test.py | Email History send 상태 전이 및 Slack 로깅 테스트 추가 |
| app/notification/test/init.py | 테스트 패키지 초기화 파일 추가 |
| app/notification/templates/nhn_cloud_sms_preview.html | SMS/MMS 프리뷰 HTML 템플릿 추가 |
| app/notification/templates/nhn_cloud_kakao_alimtalk_preview.html | 카카오 알림톡 프리뷰 HTML 템플릿 추가 |
| app/notification/templates/email_preview.html | 이메일 프리뷰 HTML 템플릿 추가 |
| app/notification/models/nhn_cloud_sms.py | NHN Cloud SMS 템플릿/히스토리 모델 및 send 파라미터 빌드 추가 |
| app/notification/models/nhn_cloud_kakao_alimtalk.py | 알림톡 템플릿 read-only 매니저, NHN 동기화, 모델/히스토리 추가 |
| app/notification/models/email.py | 이메일 템플릿/히스토리 모델 및 send 파라미터 빌드 추가 |
| app/notification/models/base.py | 템플릿 렌더링/변수추출, History send 상태/Slack 로깅 공통 베이스 추가 |
| app/notification/models/init.py | notification 모델 export 구성 |
| app/notification/migrations/0001_initial.py | notification 앱 초기 마이그레이션 추가 |
| app/notification/apps.py | notification AppConfig 추가 |
| app/notification/init.py | notification 패키지 초기화 파일 추가 |
| app/external_api/google_oauth2/views.py | OAuth2 authorize에서 code_verifier 세션 저장 및 redirect에서 복원 |
| app/core/util/google_api.py | authorization URL 생성 시 code_verifier 반환하도록 변경 |
| app/core/test/nhn_cloud_sms_test.py | NHN Cloud SMS client 입력 검증/엔드포인트 분기/로깅 테스트 추가 |
| app/core/test/models_test.py | QuerySet.update/bulk_update의 updated_by 주입/보존 회귀 테스트 추가 |
| app/core/test/email_backends_test.py | Gmail OAuth2 백엔드 토큰 캐싱 및 XOAUTH2 인증 테스트 추가 |
| app/core/test/init.py | core 테스트 패키지 초기화 파일 추가 |
| app/core/settings.py | notification 앱/템플릿 경로, 이메일/NHN Cloud 설정 추가 |
| app/core/openapi/schemas.py | HTML 응답 스키마 헬퍼(build_html_responses) 추가 |
| app/core/models.py | QuerySet.update updated_by 주입 조건 개선 및 select_related_with_user 추가 |
| app/core/external_apis/smtp_email.py | SMTP 이메일 발송 클라이언트 추가 |
| app/core/external_apis/nhn_cloud_sms.py | NHN Cloud SMS 발송 클라이언트 추가 |
| app/core/external_apis/nhn_cloud_kakao_alimtalk.py | NHN Cloud 알림톡 발송/조회 클라이언트 추가 |
| app/core/external_apis/interface.py | 외부 발송 인터페이스 및 SendParameters 타입 추가 |
| app/core/email_backends.py | Gmail OAuth2 SMTP EmailBackend 구현 및 토큰 캐싱 추가 |
| app/core/const/tag.py | Admin 알림 관련 OpenAPI tag 추가 |
| app/admin_api/views/notification.py | 어드민 알림 템플릿/히스토리 CRUD 및 render/history/retry 액션 추가 |
| app/admin_api/urls.py | 어드민 알림 라우팅(notification/email |
| app/admin_api/test/notification_test.py | 어드민 알림 API 전반(CRUD/필터/프리뷰/히스토리/재시도) 테스트 추가 |
| app/admin_api/test/conftest.py | 어드민 알림 API 테스트용 fixture 및 thread_local 격리 추가 |
| app/admin_api/serializers/notification.py | 어드민 알림 serializer(템플릿 변수 노출/프리뷰/히스토리 생성) 추가 |
| app/admin_api/filtersets/notification.py | 어드민 알림 템플릿/히스토리 필터 추가 |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| context[key] = f"RandomValue-{uuid4().hex[:8]}" | ||
| case UnhandledVariableHandling.REMOVE: | ||
| context[key] = "" | ||
|
|
||
| return json_loads(template.render(Context(context))) |
There was a problem hiding this comment.
render() builds a JSON string via Django template substitution and then immediately json_loads(...). If any context value contains quotes/newlines or non-string JSON types (e.g., booleans render as True), the rendered output can become invalid JSON (or allow JSON-structure injection), causing runtime failures or unintended payloads. Consider parsing self.data with json.loads first and then rendering only the string leaf values (or JSON-encoding inserted values) so the final payload remains valid JSON regardless of context content.
| url, _ = flow.authorization_url( | ||
| prompt=prompt, | ||
| access_type=access_type, | ||
| include_granted_scopes="true" if include_granted_scopes else "false", | ||
| )[0] | ||
| ) |
There was a problem hiding this comment.
flow.authorization_url() returns a (url, state) tuple; the state is currently discarded. For OAuth2, the state parameter should be persisted (e.g., in session) and validated on the redirect callback to prevent CSRF/session fixation issues. Please return the state (or store it) and ensure the redirect handler verifies it before exchanging the code.
| url, code_verifier = create_authorization_url(flow=flow) | ||
| request.session["google_oauth2_code_verifier"] = code_verifier | ||
| return redirect(url) |
There was a problem hiding this comment.
The authorization step stores code_verifier in session but does not persist/validate the OAuth2 state value. To properly defend against CSRF, store state from authorization_url() in the session here and validate the incoming state query param in the redirect handler before calling fetch_credentials().
| return SendParameters( | ||
| payload=self.context, | ||
| send_to=self.send_to, | ||
| template_code=self.template_code, | ||
| sent_from=self.template.sender_key, |
There was a problem hiding this comment.
NHNCloudKakaoAlimTalkNotificationHistory sends payload=self.context without validating required template variables. Unlike Email/SMS (which fail-fast via template.render()), this can silently send requests missing required templateParameter keys, leading to provider-side errors or incomplete messages. Consider validating self.context against self.template.template_variables (and raising ValueError listing missing vars) before building send parameters.
| @@ -19,7 +19,9 @@ def create(self, **kwargs: dict) -> models.Model: | |||
| return super().create(**(kwargs | {"created_by": current_user, "updated_by": current_user})) | |||
|
|
|||
| def update(self, **kwargs: dict) -> typing.Self: | |||
There was a problem hiding this comment.
BaseAbstractModelQuerySet.update() is annotated as returning typing.Self, but QuerySet.update() returns an int (number of rows updated). This mismatch can confuse callers and type checkers. Update the return annotation to int (and adjust any dependent typing if needed).
| def update(self, **kwargs: dict) -> typing.Self: | |
| def update(self, **kwargs: dict) -> int: |
| class NHNCloudSMSNotificationTemplate(NotificationTemplateBase): | ||
| html_template_name: ClassVar[str] = "nhn_cloud_sms_preview.html" | ||
|
|
||
| from_no = models.CharField(max_length=13, null=True, blank=True) | ||
|
|
There was a problem hiding this comment.
from_no is nullable/blank, but NHNCloudSMSClient.send_message() raises when sent_from is falsy. As a result, the API currently allows creating SMS templates that can never be sent (will always transition histories to FAILED). Consider making from_no required at the model/serializer level (or adding explicit validation) so misconfigured templates are rejected earlier.
earthyoung
left a comment
There was a problem hiding this comment.
테스트랑 template 파일들은 하나하나 세부적으로 보진 못했습니다...ㅠㅠ
개발하느라 고생하셨습니다. LGTM입니다!
| with suppress(Exception): | ||
| history.send() | ||
|
|
||
| return Response(data=self.get_serializer(history).data) |
There was a problem hiding this comment.
Exception이 발생하면 로깅을 하거나 노티를 주는 로직도 추가하면 좋을 것 같습니다!
| ADMIN_NOTI_EMAIL = "Admin > Notification > Email" | ||
| ADMIN_NOTI_KAKAO_ALIMTALK = "Admin > Notification > Kakao Alimtalk" | ||
| ADMIN_NOTI_SMS = "Admin > Notification > SMS" |
There was a problem hiding this comment.
요거는 Tag로 level을 나타내는 건가요..! 좋은 것 같습니다!
| @pytest.mark.django_db | ||
| def test_queryset_bulk_update_does_not_raise_duplicate_column(template, other_user): | ||
| # bulk_update는 내부적으로 `update(updated_by_id=Case(...))`를 호출 → 자동 주입과 충돌하면 안 됨 | ||
| template.title = "bulked" | ||
| template.updated_by = other_user | ||
| EmailNotificationTemplate.objects.bulk_update([template], fields=["title", "updated_by"]) | ||
|
|
||
| template.refresh_from_db() | ||
| assert template.title == "bulked" | ||
| assert template.updated_by_id == other_user.id |
There was a problem hiding this comment.
이런 구체적인 테스트케이스 작성해주신 부분 넘 좋은 것 같습니다!
주요 변경 사항
배경 사항
운영진이 참가자에게 알림을 보낼 수 있도록 기능을 제공합니다.